spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound, permanentRedirect } from 'next/navigation';4import { ExternalLink } from 'lucide-react';5import { PageHeader, Section, KV, Note } from '@/components/ui/section';6import { Badge } from '@/components/ui/badge';7import { EmptyState } from '@/components/ui/empty-state';8import { Freshness } from '@/components/ui/freshness';9import { Pager } from '@/components/ui/pager';10import { EvidenceTable } from '@/components/data/evidence-table';11import { GeneFrequencyTable } from '@/components/data/frequency-table';12import { getGeneBySymbol, variantsForGene, variantsForGeneCount, frequenciesForGene, VARIANT_PAGE_SIZE } from '@/lib/queries/genomics';13import { evidenceForGene, evidenceForGeneCount, evidenceCancersForGene, EVIDENCE_PAGE_SIZE } from '@/lib/queries/evidence';14import { loadProvenance } from '@/lib/queries/provenance';15import { recentPublicationsFor, recentPublicationsForCount, PUBLICATION_PAGE_SIZE } from '@/lib/queries/publications';16import { PublicationList } from '@/components/data/publication-list';17import { GraphLink } from '@/components/graph/graph-link';18import { fmtInt, humanize } from '@/lib/format';19import { pageInfo } from '@/lib/pagination';20import { int, withParams, type SP } from '@/lib/search-params';2122export const revalidate = 3600;23/** Cancer chips above the evidence table: the most-cited contexts only (every row still names its cancer). */24const CANCER_CHIPS = 20;2526export async function generateMetadata({ params }: { params: Promise<{ symbol: string }> }): Promise<Metadata> {27 const g = await getGeneBySymbol((await params).symbol);28 return g ? { title: `${g.symbol} — gene`, description: `${g.symbol}${g.name ? ` (${g.name})` : ''}: curated cancer evidence, variants and cohort alteration frequencies.`, alternates: { canonical: `/gene/${g.symbol}` } } : { title: 'Gene' };29}3031export default async function GenePage({ params, searchParams }: { params: Promise<{ symbol: string }>; searchParams: Promise<SP> }) {32 const { symbol } = await params;33 const g = await getGeneBySymbol(symbol);34 if (!g) notFound();35 if (g.symbol !== symbol) permanentRedirect(`/gene/${g.symbol}`);36 const sp = await searchParams;37 const evPageReq = int(sp, 'evPage', 1, 1, 100_000);38 const vPageReq = int(sp, 'vPage', 1, 1, 100_000);39 const cPageReq = int(sp, 'cPage', 1, 1, 100_000);40 const pPageReq = int(sp, 'pPage', 1, 1, 100_000);4142 const [evTotal, vTotal, pTotal] = await Promise.all([evidenceForGeneCount(g.id, g.symbol), variantsForGeneCount(g.id), recentPublicationsForCount('gene', [g.id])]);43 const ev = pageInfo(evPageReq, EVIDENCE_PAGE_SIZE, evTotal);44 const vp = pageInfo(vPageReq, VARIANT_PAGE_SIZE, vTotal);45 const pp = pageInfo(pPageReq, PUBLICATION_PAGE_SIZE, pTotal);46 const [variants, evidence, freqs, pubs, cancersInEvidence] = await Promise.all([47 vTotal ? variantsForGene(g.id, { page: vp.page, pageSize: vp.pageSize }) : Promise.resolve([]),48 evTotal ? evidenceForGene(g.id, g.symbol, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]),49 frequenciesForGene(g.id, g.symbol),50 pTotal ? recentPublicationsFor('gene', [g.id], { page: pp.page, pageSize: pp.pageSize }) : Promise.resolve([]),51 evTotal ? evidenceCancersForGene(g.id, g.symbol, CANCER_CHIPS) : Promise.resolve([]),52 ]);53 const prov = await loadProvenance([...evidence.map((e) => e.provenance_id), ...freqs.map((f) => f.provenance_id)]);5455 const current = { evPage: ev.page > 1 ? ev.page : '', vPage: vp.page > 1 ? vp.page : '', pPage: pp.page > 1 ? pp.page : '', cPage: cPageReq > 1 ? cPageReq : '' };56 const href = (o: Record<string, string | number | null | undefined>, hash: string) => `/gene/${g.symbol}${withParams(current, o)}#${hash}`;5758 return (59 <article>60 <PageHeader kicker="Gene" title={<span className="ci-mono font-sans">{g.symbol}</span>} lede={g.name ?? undefined}>61 <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]">62 <GraphLink type="gene" entityRef={g.symbol} />63 <span className="ci-mono text-ink-3">{g.id}</span>64 {g.hgnc_id ? (65 <a className="ci-link inline-flex items-center gap-1" href={`https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/${g.hgnc_id}`} target="_blank" rel="noopener noreferrer">66 {g.hgnc_id} <ExternalLink className="h-3 w-3" aria-hidden />67 </a>68 ) : null}69 {g.ensembl_gene_id ? (70 <a className="ci-link inline-flex items-center gap-1" href={`https://www.ensembl.org/Homo_sapiens/Gene/Summary?g=${g.ensembl_gene_id}`} target="_blank" rel="noopener noreferrer">71 {g.ensembl_gene_id} <ExternalLink className="h-3 w-3" aria-hidden />72 </a>73 ) : null}74 {g.ncbi_gene_id ? (75 <a className="ci-link inline-flex items-center gap-1" href={`https://www.ncbi.nlm.nih.gov/gene/${g.ncbi_gene_id}`} target="_blank" rel="noopener noreferrer">76 NCBI {g.ncbi_gene_id} <ExternalLink className="h-3 w-3" aria-hidden />77 </a>78 ) : null}79 {g.civic_gene_id ? (80 <a className="ci-link inline-flex items-center gap-1" href={`https://civicdb.org/genes/${g.civic_gene_id}/summary`} target="_blank" rel="noopener noreferrer">81 CIViC <ExternalLink className="h-3 w-3" aria-hidden />82 </a>83 ) : null}84 {g.is_cancer_gene ? <Badge tone="accent" title="Has at least one curated cancer edge (derived flag)">Cancer gene</Badge> : null}85 <Badge tone="outline">{g.status}</Badge>86 </p>87 </PageHeader>8889 <div className="grid gap-8 lg:grid-cols-[1fr_320px]">90 <div className="space-y-8">91 {g.description ? (92 <Section id="summary" kicker="Summary" title="Description">93 <p className="max-w-3xl text-[15px] leading-relaxed">{g.description}</p>94 </Section>95 ) : null}9697 <Section id="evidence" kicker="Curated evidence" title={`Clinical evidence (${fmtInt(evTotal)})`} description={`CIViC items involving this gene, grouped by molecular profile and therapy, with native levels and directions. ${EVIDENCE_PAGE_SIZE} items per page.`}>98 {evTotal ? (99 <>100 {cancersInEvidence.length ? (101 <nav aria-label="Cancers in evidence" className="mb-3">102 <ul className="m-0 flex list-none flex-wrap gap-1.5 p-0 text-[12.5px]">103 <li className="ci-kicker mr-1 self-center">{cancersInEvidence.length >= CANCER_CHIPS ? `Top ${CANCER_CHIPS} cancers` : 'Cancers'}</li>104 {cancersInEvidence.map((c) => (105 <li key={c.slug}>106 <Link href={`/cancer/${c.slug}/evidence`} className="ci-chip">107 {c.name} <span className="ci-num text-ink-3">{c.n}</span>108 </Link>109 </li>110 ))}111 </ul>112 </nav>113 ) : null}114 <EvidenceTable115 items={evidence}116 prov={prov}117 showCancer118 summary={119 <>120 Showing {fmtInt(ev.from)}–{fmtInt(ev.to)} of {fmtInt(evTotal)} evidence items121 </>122 }123 />124 <Pager total={evTotal} pageSize={ev.pageSize} page={ev.page} hrefFor={(p) => href({ evPage: p > 1 ? p : '' }, 'evidence')} label="Evidence pages" noun="evidence items" />125 </>126 ) : (127 <EmptyState compact>No curated evidence item involves this gene yet.</EmptyState>128 )}129 </Section>130131 <Section id="frequencies" kicker="Cohorts" title={`Alteration frequency by cohort (${fmtInt(freqs.length)})`} description="Frequency = cases affected / cases profiled within one cohort. Cohorts are never pooled.">132 {freqs.length ? <GeneFrequencyTable rows={freqs} prov={prov} page={cPageReq} hrefFor={(p) => href({ cPage: p > 1 ? p : '' }, 'frequencies')} /> : <EmptyState compact>No cohort frequency recorded for this gene.</EmptyState>}133 </Section>134135 <Section id="publications" kicker="Literature" title={`Linked publications (${fmtInt(pTotal)})`} description={pTotal ? `${PUBLICATION_PAGE_SIZE} per page, newest first.` : undefined}>136 {pubs.length ? (137 <>138 <PublicationList139 rows={pubs}140 summary={141 <>142 Showing {fmtInt(pp.from)}–{fmtInt(pp.to)} of {fmtInt(pTotal)} publications143 </>144 }145 />146 <Pager total={pTotal} pageSize={pp.pageSize} page={pp.page} hrefFor={(p) => href({ pPage: p > 1 ? p : '' }, 'publications')} label="Publication pages" noun="publications" />147 </>148 ) : (149 <EmptyState compact>No publication linked to this gene yet.</EmptyState>150 )}151 </Section>152 </div>153154 <aside className="space-y-8">155 <Section id="identity" kicker="HGNC" title="Record" level={3}>156 <KV157 items={[158 { k: 'Location', v: g.location ? <span className="ci-mono">{g.location}</span> : null },159 { k: 'Chromosome', v: g.chromosome },160 { k: 'Locus type', v: g.locus_type },161 { k: 'Locus group', v: g.locus_group },162 { k: 'Previous symbols', v: g.prev_symbols.length ? g.prev_symbols.join(', ') : null },163 { k: 'Alias symbols', v: g.alias_symbols.length ? g.alias_symbols.join(', ') : null },164 { k: 'Gene families', v: g.gene_families.length ? g.gene_families.join('; ') : null },165 { k: 'UniProt', v: g.uniprot_ids.length ? g.uniprot_ids.join(', ') : null },166 { k: 'OMIM', v: g.omim_ids.length ? g.omim_ids.join(', ') : null },167 { k: 'RefSeq', v: g.refseq_accession },168 ]}169 />170 <Freshness dataUpdatedAt={g.updated_at} extra="source: hgnc" />171 </Section>172 <Section id="variants" kicker="Variants" title={`Variants (${fmtInt(vTotal)})`} description={vTotal > VARIANT_PAGE_SIZE ? `Sorted by evidence count, ${VARIANT_PAGE_SIZE} per page.` : undefined} level={3}>173 {variants.length ? (174 <>175 <ul className="ci-rows">176 {variants.map((v) => (177 <li key={v.id}>178 <Link className="ci-link" href={`/variant/${v.slug}`}>179 {v.name}180 </Link>181 <span>182 {v.variant_type ? humanize(v.variant_type) : ''}183 {v.evidence_count ? ` · ${v.evidence_count} ev.` : ''}184 </span>185 </li>186 ))}187 </ul>188 <Pager total={vTotal} pageSize={vp.pageSize} page={vp.page} hrefFor={(p) => href({ vPage: p > 1 ? p : '' }, 'variants')} label="Variant pages" noun="variants" className="text-[12px]" />189 </>190 ) : (191 <p className="text-[13px] text-ink-3">No variant entity recorded for this gene.</p>192 )}193 </Section>194 <Note>Gene-level pages aggregate curated evidence across cancers; the cancer context of each item is shown in the table and must not be generalized.</Note>195 </aside>196 </div>197 </article>198 );199}200